diff --git a/ApiProxy.cs b/ApiProxy.cs new file mode 100644 index 0000000..f84f86a --- /dev/null +++ b/ApiProxy.cs @@ -0,0 +1,25 @@ +using System; + +namespace CryptoExchange.Net +{ + public class ApiProxy + { + /// + /// The host address of the proxy + /// + public string Host { get; } + /// + /// The port of the proxy + /// + public int Port { get; } + + public ApiProxy(string host, int port) + { + if(string.IsNullOrEmpty(host) || port <= 0) + throw new ArgumentException("Proxy host or port not filled"); + + Host = host; + Port = port; + } + } +} diff --git a/Authentication/ApiCredentials.cs b/Authentication/ApiCredentials.cs new file mode 100644 index 0000000..141ff6a --- /dev/null +++ b/Authentication/ApiCredentials.cs @@ -0,0 +1,27 @@ +using System; + +namespace CryptoExchange.Net.Authentication +{ + public class ApiCredentials + { + /// + /// The api key + /// + public string Key { get; } + /// + /// The api secret + /// + public string Secret { get; } + + public ApiCredentials() { } + + public ApiCredentials(string key, string secret) + { + if(string.IsNullOrEmpty(key) || string.IsNullOrEmpty(secret)) + throw new ArgumentException("Apikey or apisecret not provided"); + + Key = key; + Secret = secret; + } + } +} diff --git a/Authentication/AuthenticationProvider.cs b/Authentication/AuthenticationProvider.cs new file mode 100644 index 0000000..d10cc3a --- /dev/null +++ b/Authentication/AuthenticationProvider.cs @@ -0,0 +1,25 @@ +using CryptoExchange.Net.Interfaces; + +namespace CryptoExchange.Net.Authentication +{ + public abstract class AuthenticationProvider + { + protected ApiCredentials credentials; + + protected AuthenticationProvider(ApiCredentials credentials) + { + this.credentials = credentials; + } + + public abstract string AddAuthenticationToUriString(string uri); + public abstract IRequest AddAuthenticationToRequest(IRequest request); + + protected string ByteToString(byte[] buff) + { + var sbinary = ""; + foreach (byte t in buff) + sbinary += t.ToString("X2"); /* hex format */ + return sbinary; + } + } +} diff --git a/BaseConverter.cs b/BaseConverter.cs new file mode 100644 index 0000000..af8cb36 --- /dev/null +++ b/BaseConverter.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace CryptoExchange.Net +{ + public abstract class BaseConverter: JsonConverter + { + protected abstract Dictionary Mapping { get; } + private readonly bool quotes; + + protected BaseConverter(bool useQuotes) + { + quotes = useQuotes; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (quotes) + writer.WriteValue(Mapping[(T)value]); + else + writer.WriteRawValue(Mapping[(T)value]); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return Mapping.Single(v => v.Value.ToLower() == reader.Value.ToString().ToLower()).Key; + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(T); + } + } +} diff --git a/CallResult.cs b/CallResult.cs new file mode 100644 index 0000000..029c68a --- /dev/null +++ b/CallResult.cs @@ -0,0 +1,24 @@ +namespace CryptoExchange.Net +{ + public class CallResult + { + /// + /// The data returned by the call + /// + public T Data { get; internal set; } + /// + /// An error if the call didn't succeed + /// + public Error Error { get; internal set; } + /// + /// Whether the call was successful + /// + public bool Success => Error == null; + + public CallResult(T data, Error error) + { + Data = data; + Error = error; + } + } +} diff --git a/CryptoExchange.Net.csproj b/CryptoExchange.Net.csproj new file mode 100644 index 0000000..7c9bc66 --- /dev/null +++ b/CryptoExchange.Net.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0 + + + + CryptoExchange.Net + JKorf + 0.0.1 + false + https://github.com/JKorf/CryptoExchange.Net + https://github.com/JKorf/CryptoExchange.Net/blob/master/LICENSE + en + true + + + + + + + diff --git a/Error.cs b/Error.cs new file mode 100644 index 0000000..4309d95 --- /dev/null +++ b/Error.cs @@ -0,0 +1,49 @@ +namespace CryptoExchange.Net +{ + public abstract class Error + { + public int Code { get; set; } + public string Message { get; set; } + + protected Error(int code, string message) + { + Code = code; + Message = message; + } + + public override string ToString() + { + return $"{Code}: {Message}"; + } + } + + public class CantConnectError : Error + { + public CantConnectError() : base(1, "Can't connect to the server") { } + } + + public class NoApiCredentialsError : Error + { + public NoApiCredentialsError() : base(2, "No credentials provided for private endpoint") { } + } + + public class ServerError: Error + { + public ServerError(string message) : base(3, "Server error: " + message) { } + } + + public class WebError : Error + { + public WebError(string message) : base(3, "Web error: " + message) { } + } + + public class DeserializeError : Error + { + public DeserializeError(string message) : base(4, "Error deserializing data: " + message) { } + } + + public class UnknownError : Error + { + public UnknownError(string message) : base(5, "Unknown error occured " + message) { } + } +} diff --git a/ExchangeClient.cs b/ExchangeClient.cs new file mode 100644 index 0000000..bb376dd --- /dev/null +++ b/ExchangeClient.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Threading.Tasks; +using CryptoExchange.Net.Authentication; +using CryptoExchange.Net.Interfaces; +using CryptoExchange.Net.Logging; +using CryptoExchange.Net.RateLimiter; +using CryptoExchange.Net.Requests; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace CryptoExchange.Net +{ + public abstract class ExchangeClient: IDisposable + { + public IRequestFactory RequestFactory { get; set; } = new RequestFactory(); + + protected Log log; + protected ApiProxy apiProxy; + + private AuthenticationProvider authProvider; + private readonly List rateLimiters = new List(); + + protected ExchangeClient(ExchangeOptions exchangeOptions, AuthenticationProvider authentictationProvider) + { + log = new Log(); + authProvider = authentictationProvider; + Configure(exchangeOptions); + } + + /// + /// Configure the client using the provided options + /// + /// Options + protected void Configure(ExchangeOptions exchangeOptions) + { + log.Level = exchangeOptions.LogVerbosity; + apiProxy = exchangeOptions.Proxy; + + foreach (var rateLimiter in exchangeOptions.RateLimiters) + rateLimiters.Add(rateLimiter); + } + + /// + /// Adds a rate limiter to the client. There are 2 choices, the and the . + /// + /// The limiter to add + public void AddRateLimiter(IRateLimiter limiter) + { + rateLimiters.Add(limiter); + } + + /// + /// Removes all rate limiters from this client + /// + public void RemoveRateLimiters() + { + rateLimiters.Clear(); + } + + /// + /// Set the authentication provider + /// + /// + protected void SetAuthenticationProvider(AuthenticationProvider authentictationProvider) + { + authProvider = authentictationProvider; + } + + protected async Task> ExecuteRequest(Uri uri, string method = "GET", Dictionary parameters = null, bool signed = false) where T : class + { + if(signed && authProvider == null) + return new CallResult(null, new NoApiCredentialsError()); + + var uriString = uri.ToString(); + + if (parameters != null) + { + if (!uriString.EndsWith("?")) + uriString += "?"; + + uriString += $"{string.Join("&", parameters.Select(s => $"{s.Key}={s.Value}"))}"; + } + + if (signed) + uriString = authProvider.AddAuthenticationToUriString(uriString); + + var request = RequestFactory.Create(uriString); + request.Method = method; + + if (apiProxy != null) + request.SetProxy(apiProxy.Host, apiProxy.Port); + + if (signed) + request = authProvider.AddAuthenticationToRequest(request); + + foreach (var limiter in rateLimiters) + { + double limitedBy = limiter.LimitRequest(uri.AbsolutePath); + if (limitedBy > 0) + log.Write(LogVerbosity.Debug, $"Request {uri.AbsolutePath} was limited by {limitedBy}ms by {limiter.GetType().Name}"); + } + + log.Write(LogVerbosity.Debug, $"Sending request to {uriString}"); + var result = await ExecuteRequest(request); + if (result.Error != null) + return new CallResult(null, result.Error); + + return Deserialize(result.Data); + } + + private async Task> ExecuteRequest(IRequest request) + { + string returnedData = ""; + try + { + var response = request.GetResponse(); + using (var reader = new StreamReader(response.GetResponseStream())) + { + returnedData = await reader.ReadToEndAsync().ConfigureAwait(false); + return new CallResult(returnedData, null); + } + } + catch (WebException we) + { + var response = (HttpWebResponse)we.Response; + string infoMessage = response == null ? "No response from server" : $"Status: {response.StatusCode}-{response.StatusDescription}, Message: {we.Message}"; + return new CallResult(null, new WebError(infoMessage)); + } + catch (Exception e) + { + return new CallResult(null, new UnknownError(e.Message + ", data: " + returnedData)); + } + } + + private CallResult Deserialize(string data) where T: class + { + try + { + var obj = JToken.Parse(data); + if (log.Level == LogVerbosity.Debug) + { + if (obj is JObject o) + CheckObject(typeof(T), o); + else + CheckObject(typeof(T), (JObject) ((JArray) obj)[0]); + } + + return new CallResult(obj.ToObject(), null); + } + catch (JsonReaderException jre) + { + return new CallResult(null, new DeserializeError($"Error occured at Path: {jre.Path}, LineNumber: {jre.LineNumber}, LinePosition: {jre.LinePosition}. Received data: {data}")); + } + catch (JsonSerializationException jse) + { + return new CallResult(null, new DeserializeError($"Message: {jse.Message}. Received data: {data}")); + } + } + + private void CheckObject(Type type, JObject obj) + { + var properties = new List(); + var props = type.GetProperties(); + foreach (var prop in props) + { + var attr = prop.GetCustomAttributes(typeof(JsonPropertyAttribute), false).FirstOrDefault(); + var ignore = prop.GetCustomAttributes(typeof(JsonIgnoreAttribute), false).FirstOrDefault(); + if (ignore != null) + continue; + + properties.Add(attr == null ? prop.Name.ToLower() : ((JsonPropertyAttribute) attr).PropertyName.ToLower()); + } + foreach (var token in obj) + { + var d = properties.SingleOrDefault(p => p == token.Key.ToLower()); + if (d == null) + log.Write(LogVerbosity.Warning, $"Didn't find property `{token.Key}` in object of type `{type.Name}`"); + else + { + properties.Remove(d); + + var propType = GetProperty(d, props)?.PropertyType; + if (propType == null) + continue; + if (!IsSimple(propType) && propType != typeof(DateTime)) + { + if(propType.IsArray && token.Value.HasValues && ((JArray)token.Value).Any() && ((JArray)token.Value)[0] is JObject) + CheckObject(propType.GetElementType(), (JObject)token.Value[0]); + else if(token.Value is JObject) + CheckObject(propType, (JObject)token.Value); + } + } + } + + foreach(var prop in properties) + log.Write(LogVerbosity.Warning, $"Didn't find key `{prop}` in returned data object of type `{type.Name}`"); + } + + private PropertyInfo GetProperty(string name, PropertyInfo[] props) + { + foreach (var prop in props) + { + var attr = prop.GetCustomAttributes(typeof(JsonPropertyAttribute), false).FirstOrDefault(); + if (attr == null) + { + if (prop.Name.ToLower() == name.ToLower()) + return prop; + } + else + { + if (((JsonPropertyAttribute) attr).PropertyName.ToLower() == name) + return prop; + } + } + return null; + } + + private bool IsSimple(Type type) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + // nullable type, check if the nested type is simple. + return IsSimple(type.GetGenericArguments()[0]); + } + return type.IsPrimitive + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal); + } + + public virtual void Dispose() + { + } + } +} diff --git a/ExchangeOptions.cs b/ExchangeOptions.cs new file mode 100644 index 0000000..dca29a1 --- /dev/null +++ b/ExchangeOptions.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.IO; +using CryptoExchange.Net.Authentication; +using CryptoExchange.Net.Logging; +using CryptoExchange.Net.RateLimiter; + +namespace CryptoExchange.Net +{ + /// + /// Options + /// + public class ExchangeOptions + { + + /// + /// The api credentials + /// + public ApiCredentials ApiCredentials { get; set; } + + /// + /// Proxy to use + /// + public ApiProxy Proxy { get; set; } + + /// + /// The log verbosity + /// + public LogVerbosity LogVerbosity { get; set; } = LogVerbosity.Warning; + + /// + /// The log writer + /// + public TextWriter LogWriter { get; set; } = new DebugTextWriter(); + + /// + /// List of ratelimiters to use + /// + public List RateLimiters { get; set; } = new List(); + } +} diff --git a/ExtensionMethods.cs b/ExtensionMethods.cs new file mode 100644 index 0000000..83dec83 --- /dev/null +++ b/ExtensionMethods.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace CryptoExchange.Net +{ + public static class ExtensionMethods + { + public static void AddParameter(this Dictionary parameters, string key, string value) + { + parameters.Add(key, value); + } + + public static void AddParameter(this Dictionary parameters, string key, object value) + { + parameters.Add(key, value); + } + + public static void AddOptionalParameter(this Dictionary parameters, string key, object value) + { + if(value != null) + parameters.Add(key, value); + } + + public static void AddOptionalParameter(this Dictionary parameters, string key, string value) + { + if (value != null) + parameters.Add(key, value); + } + } +} diff --git a/Interfaces/IExchangeClient.cs b/Interfaces/IExchangeClient.cs new file mode 100644 index 0000000..0f37f72 --- /dev/null +++ b/Interfaces/IExchangeClient.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Text; +using CryptoExchange.Net.RateLimiter; + +namespace CryptoExchange.Net.Interfaces +{ + public interface IExchangeClient + { + IRequestFactory RequestFactory { get; set; } + void AddRateLimiter(IRateLimiter limiter); + void RemoveRateLimiters(); + } +} diff --git a/Interfaces/IRequest.cs b/Interfaces/IRequest.cs new file mode 100644 index 0000000..f17fff3 --- /dev/null +++ b/Interfaces/IRequest.cs @@ -0,0 +1,15 @@ +using System; +using System.Net; + +namespace CryptoExchange.Net.Interfaces +{ + public interface IRequest + { + Uri Uri { get; } + WebHeaderCollection Headers { get; set; } + string Method { get; set; } + + void SetProxy(string host, int port); + IResponse GetResponse(); + } +} diff --git a/Interfaces/IRequestFactory.cs b/Interfaces/IRequestFactory.cs new file mode 100644 index 0000000..1bbf5ac --- /dev/null +++ b/Interfaces/IRequestFactory.cs @@ -0,0 +1,8 @@ + +namespace CryptoExchange.Net.Interfaces +{ + public interface IRequestFactory + { + IRequest Create(string uri); + } +} diff --git a/Interfaces/IResponse.cs b/Interfaces/IResponse.cs new file mode 100644 index 0000000..cdf6da2 --- /dev/null +++ b/Interfaces/IResponse.cs @@ -0,0 +1,9 @@ +using System.IO; + +namespace CryptoExchange.Net.Interfaces +{ + public interface IResponse + { + Stream GetResponseStream(); + } +} diff --git a/Logging/DebugTextWriter.cs b/Logging/DebugTextWriter.cs new file mode 100644 index 0000000..9cea25b --- /dev/null +++ b/Logging/DebugTextWriter.cs @@ -0,0 +1,16 @@ +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace CryptoExchange.Net.Logging +{ + public class DebugTextWriter: TextWriter + { + public override Encoding Encoding => Encoding.ASCII; + + public override void WriteLine(string value) + { + Debug.WriteLine(value); + } + } +} diff --git a/Logging/Log.cs b/Logging/Log.cs new file mode 100644 index 0000000..60fdb79 --- /dev/null +++ b/Logging/Log.cs @@ -0,0 +1,25 @@ +using System; +using System.IO; + +namespace CryptoExchange.Net.Logging +{ + public class Log + { + public TextWriter TextWriter { get; internal set; } = new DebugTextWriter(); + public LogVerbosity Level { get; internal set; } = LogVerbosity.Warning; + + public void Write(LogVerbosity logType, string message) + { + if ((int)logType >= (int)Level) + TextWriter.WriteLine($"{DateTime.Now:hh:mm:ss:fff} | {logType} | {message}"); + } + } + + public enum LogVerbosity + { + Debug, + Warning, + Error, + None + } +} diff --git a/RateLimiter/IRateLimiter.cs b/RateLimiter/IRateLimiter.cs new file mode 100644 index 0000000..4a1d5c9 --- /dev/null +++ b/RateLimiter/IRateLimiter.cs @@ -0,0 +1,7 @@ +namespace CryptoExchange.Net.RateLimiter +{ + public interface IRateLimiter + { + double LimitRequest(string url); + } +} diff --git a/RateLimiter/RateLimitObject.cs b/RateLimiter/RateLimitObject.cs new file mode 100644 index 0000000..04efdb6 --- /dev/null +++ b/RateLimiter/RateLimitObject.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CryptoExchange.Net.RateLimiter +{ + public class RateLimitObject + { + public object LockObject { get; } + private List Times { get; } + + public RateLimitObject() + { + LockObject = new object(); + Times = new List(); + } + + public double GetWaitTime(DateTime time, int limit, TimeSpan perTimePeriod) + { + Times.RemoveAll(d => d < time - perTimePeriod); + if (Times.Count >= limit) + return (Times.First() - (time - perTimePeriod)).TotalMilliseconds; + return 0; + } + + public void Add(DateTime time) + { + Times.Add(time); + Times.Sort(); + } + } +} diff --git a/RateLimiter/RateLimiterPerEndpoint.cs b/RateLimiter/RateLimiterPerEndpoint.cs new file mode 100644 index 0000000..2586a1e --- /dev/null +++ b/RateLimiter/RateLimiterPerEndpoint.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; + +namespace CryptoExchange.Net.RateLimiter +{ + /// + /// Limits the amount of requests per time period to a certain limit, counts the request per endpoint. + /// + public class RateLimiterPerEndpoint: IRateLimiter + { + internal Dictionary history = new Dictionary(); + + private int limitPerEndpoint; + private TimeSpan perTimePeriod; + private object historyLock = new object(); + + /// + /// Create a new RateLimiterPerEndpoint. This rate limiter limits the amount of requests per time period to a certain limit, counts the request per endpoint. + /// + /// The amount to limit to + /// The time period over which the limit counts + public RateLimiterPerEndpoint(int limitPerEndpoint, TimeSpan perTimePeriod) + { + this.limitPerEndpoint = limitPerEndpoint; + this.perTimePeriod = perTimePeriod; + } + + public double LimitRequest(string url) + { + double waitTime; + RateLimitObject rlo; + lock (historyLock) + { + if (history.ContainsKey(url)) + rlo = history[url]; + else + { + rlo = new RateLimitObject(); + history.Add(url, rlo); + } + } + + var sw = Stopwatch.StartNew(); + lock (rlo.LockObject) + { + sw.Stop(); + waitTime = rlo.GetWaitTime(DateTime.UtcNow, limitPerEndpoint, perTimePeriod); + if (waitTime != 0) + { + Thread.Sleep(Convert.ToInt32(waitTime)); + waitTime += sw.ElapsedMilliseconds; + } + + rlo.Add(DateTime.UtcNow); + } + + return waitTime; + } + } +} diff --git a/RateLimiter/RateLimiterTotal.cs b/RateLimiter/RateLimiterTotal.cs new file mode 100644 index 0000000..67f72c4 --- /dev/null +++ b/RateLimiter/RateLimiterTotal.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; + +namespace CryptoExchange.Net.RateLimiter +{ + /// + /// Limits the amount of requests per time period to a certain limit, counts the total amount of requests. + /// + public class RateLimiterTotal: IRateLimiter + { + internal List history = new List(); + + private int limit; + private TimeSpan perTimePeriod; + private object requestLock = new object(); + + /// + /// Create a new RateLimiterTotal. This rate limiter limits the amount of requests per time period to a certain limit, counts the total amount of requests. + /// + /// The amount to limit to + /// The time period over which the limit counts + public RateLimiterTotal(int limit, TimeSpan perTimePeriod) + { + this.limit = limit; + this.perTimePeriod = perTimePeriod; + } + + public double LimitRequest(string url) + { + var sw = Stopwatch.StartNew(); + lock (requestLock) + { + sw.Stop(); + double waitTime = 0; + var checkTime = DateTime.UtcNow; + history.RemoveAll(d => d < checkTime - perTimePeriod); + + if (history.Count >= limit) + { + waitTime = (history.First() - (checkTime - perTimePeriod)).TotalMilliseconds; + if (waitTime > 0) + { + Thread.Sleep(Convert.ToInt32(waitTime)); + waitTime += sw.ElapsedMilliseconds; + } + } + + history.Add(DateTime.UtcNow); + history.Sort(); + return waitTime; + } + } + } +} diff --git a/Requests/Request.cs b/Requests/Request.cs new file mode 100644 index 0000000..b4ac071 --- /dev/null +++ b/Requests/Request.cs @@ -0,0 +1,41 @@ +using System; +using System.Net; +using CryptoExchange.Net.Interfaces; + +namespace CryptoExchange.Net.Requests +{ + public class Request : IRequest + { + private readonly WebRequest request; + + public Request(WebRequest request) + { + this.request = request; + } + + public WebHeaderCollection Headers + { + get => request.Headers; + set => request.Headers = value; + } + public string Method + { + get => request.Method; + set => request.Method = value; + } + public Uri Uri + { + get => request.RequestUri; + } + + public void SetProxy(string host, int port) + { + request.Proxy = new WebProxy(host, port); ; + } + + public IResponse GetResponse() + { + return new Response(request.GetResponse()); + } + } +} diff --git a/Requests/RequestFactory.cs b/Requests/RequestFactory.cs new file mode 100644 index 0000000..4469293 --- /dev/null +++ b/Requests/RequestFactory.cs @@ -0,0 +1,13 @@ +using System.Net; +using CryptoExchange.Net.Interfaces; + +namespace CryptoExchange.Net.Requests +{ + public class RequestFactory : IRequestFactory + { + public IRequest Create(string uri) + { + return new Request(WebRequest.Create(uri)); + } + } +} diff --git a/Requests/Response.cs b/Requests/Response.cs new file mode 100644 index 0000000..f4da0af --- /dev/null +++ b/Requests/Response.cs @@ -0,0 +1,21 @@ +using System.IO; +using System.Net; +using CryptoExchange.Net.Interfaces; + +namespace CryptoExchange.Net.Requests +{ + public class Response : IResponse + { + private readonly WebResponse response; + + public Response(WebResponse response) + { + this.response = response; + } + + public Stream GetResponseStream() + { + return response.GetResponseStream(); + } + } +}